Maîtrisez la conception axée sur le domaine en JavaScript. Apprenez le modèle d'entité de module pour créer des applications évolutives, testables et maintenables avec des modèles d'objets de domaine robustes.
Modèles d'entités de modules JavaScript : Un examen approfondi de la modélisation d'objets de domaine
Dans le monde du développement logiciel, en particulier au sein de l'écosystème JavaScript dynamique et en constante évolution, nous accordons souvent la priorité à la vitesse, aux frameworks et aux fonctionnalités. Nous créons des interfaces utilisateur complexes, nous nous connectons à d'innombrables API et nous déployons des applications à un rythme effréné. Mais dans cette précipitation, nous négligeons parfois le cœur même de notre application : le domaine métier. Cela peut conduire à ce que l'on appelle souvent le "Big Ball of Mud" (Gros tas de boue) : un système où la logique métier est dispersée, les données ne sont pas structurées et où une simple modification peut déclencher une cascade de bogues imprévus.
C'est là qu'intervient la modélisation d'objets de domaine. Il s'agit de la pratique consistant à créer un modèle riche et expressif de l'espace problématique dans lequel vous travaillez. Et en JavaScript, le modèle d'entité de module est un moyen puissant, élégant et indépendant du framework pour y parvenir. Ce guide complet vous présentera la théorie, la pratique et les avantages de ce modèle, vous permettant de créer des applications plus robustes, évolutives et maintenables.
Qu'est-ce que la modélisation d'objets de domaine ?
Avant de nous plonger dans le modèle lui-même, clarifions nos termes. Il est essentiel de distinguer ce concept du Document Object Model (DOM) du navigateur.
- Domaine : Dans le logiciel, le « domaine » est le domaine spécifique auquel appartient l'activité de l'utilisateur. Pour une application de commerce électronique, le domaine comprend des concepts tels que les produits, les clients, les commandes et les paiements. Pour une plateforme de médias sociaux, il comprend les utilisateurs, les publications, les commentaires et les likes.
- Modélisation d'objets de domaine : Il s'agit du processus de création d'un modèle logiciel qui représente les entités, leurs comportements et leurs relations au sein de ce domaine métier. Il s'agit de traduire des concepts du monde réel en code.
Un bon modèle de domaine n'est pas qu'une simple collection de conteneurs de données. C'est une représentation vivante de vos règles métier. Un objet Commande ne doit pas seulement contenir une liste d'articles ; il doit savoir calculer son total, comment ajouter un nouvel article et s'il peut être annulé. Cette encapsulation des données et du comportement est la clé pour construire un noyau d'application résilient.
Le problème courant : l'anarchie dans la couche « Modèle »
Dans de nombreuses applications JavaScript, en particulier celles qui se développent de manière organique, la couche « modèle » est souvent une idée après coup. Nous observons fréquemment cet anti-modèle :
// Quelque part dans un contrôleur ou un service API...
async function createUser(req, res) {
const { email, password, firstName, lastName } = req.body;
// La logique métier et la validation sont dispersées ici
if (!email || !email.includes('@')) {
return res.status(400).send({ error: 'Une adresse e-mail valide est requise.' });
}
if (!password || password.length < 8) {
return res.status(400).send({ error: 'Le mot de passe doit contenir au moins 8 caractères.' });
}
const user = {
email: email.toLowerCase(),
password: await hashPassword(password), // Une fonction utilitaire
fullName: `${firstName} ${lastName}`, // La logique pour les données dérivées se trouve ici
createdAt: new Date()
};
// Maintenant, qu'est-ce que `user` ? C'est juste un objet simple.
// Rien n'empêche un autre développeur de faire cela plus tard :
// user.email = 'an-invalid-email';
// user.password = 'short';
await db.users.insert(user);
res.status(201).send(user);
}
Cette approche pose plusieurs problèmes critiques :
- Aucune source unique de vérité : Les règles qui définissent ce qui constitue un « utilisateur » valide sont définies à l'intérieur de ce contrôleur. Que se passe-t-il si une autre partie du système doit créer un utilisateur ? Copiez-vous la logique ? Cela conduit à l'incohérence et aux bogues.
- Modèle de domaine anémique : L'objet `user` n'est qu'un simple sac de données « muet ». Il n'a ni comportement ni conscience de lui-même. Toute la logique qui l'exploite vit en externe.
- Faible cohésion : La logique de création du nom complet d'un utilisateur est mélangée à la gestion des requêtes/réponses de l'API et au hachage des mots de passe.
- Difficile à tester : Pour tester la logique de création d'un utilisateur, vous devez simuler les requêtes et réponses HTTP, les bases de données et les fonctions de hachage. Vous ne pouvez pas simplement tester le concept d'« utilisateur » isolément.
- Contrats implicites : Le reste de l'application doit simplement « supposer » que tout objet représentant un utilisateur a une certaine forme et que ses données sont valides. Il n'y a aucune garantie.
La solution : le modèle d'entité de module JavaScript
Le modèle d'entité de module résout ces problèmes en utilisant un module JavaScript standard (un seul fichier) pour définir tout ce qui concerne un concept de domaine unique. Ce module devient la source de vérité définitive pour cette entité.
Une entité de module expose généralement une fonction de fabrique. Cette fonction est chargée de créer une instance valide de l'entité. L'objet qu'elle renvoie n'est pas seulement des données ; c'est un objet de domaine riche qui encapsule ses propres données, sa validation et sa logique métier.
Principales caractéristiques d'une entité de module
- Encapsulation : Il regroupe les données et les fonctions qui exploitent ces données.
- Validation à la limite : Il garantit qu'il est impossible de créer une entité non valide. Il protège son propre état.
- API claire : Il expose un ensemble de fonctions propre et intentionnel (une API publique) pour interagir avec l'entité, tout en masquant les détails de l'implémentation interne.
- Immuabilité : Il produit souvent des objets immuables ou en lecture seule pour empêcher les modifications accidentelles de l'état et garantir un comportement prévisible.
- Portabilité : Il n'a aucune dépendance vis-à-vis des frameworks (comme Express, React) ou des systèmes externes (comme les bases de données, les API). C'est de la pure logique métier.
Composants principaux d'une entité de module
Reconstruisons notre concept `User` en utilisant ce modèle. Nous allons créer un fichier, `user.js` (ou `user.ts` pour les utilisateurs de TypeScript), et le construire étape par étape.
1. La fonction de fabrique : votre constructeur d'objets
Au lieu de classes, nous allons utiliser une fonction de fabrique (par exemple, `buildUser`). Les fabriques offrent une grande flexibilité, évitent de se débattre avec le mot-clé `this` et rendent l'état privé et l'encapsulation plus naturels en JavaScript.
Notre objectif est de créer une fonction qui prend des données brutes et renvoie un objet User bien formé et fiable.
// fichier : /domain/user.js
export default function buildMakeUser() {
// Cette fonction interne est la fabrique réelle.
// Elle a accès à toutes les dépendances transmises à buildMakeUser, si nécessaire.
return function makeUser({
id = generateId(), // Supposons une fonction pour générer un ID unique
firstName,
lastName,
email,
passwordHash,
createdAt = new Date()
}) {
// ... la validation et la logique iront ici ...
const user = {
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => email,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
};
// Utilisation de Object.freeze pour rendre l'objet immuable.
return Object.freeze(user);
}
}
Remarquez quelques choses ici. Nous utilisons une fonction qui renvoie une fonction (une fonction d'ordre supérieur). Il s'agit d'un modèle puissant pour injecter des dépendances, comme un générateur d'ID unique ou une bibliothèque de validation, sans coupler l'entité à une implémentation spécifique. Pour l'instant, nous allons faire simple.
2. Validation des données : le gardien à la porte
Une entité doit protéger sa propre intégrité. Il doit être impossible de créer un `User` dans un état non valide. Nous ajoutons une validation directement à l'intérieur de la fonction de fabrique. Si les données ne sont pas valides, la fabrique doit lever une erreur, indiquant clairement ce qui ne va pas.
// fichier : /domain/user.js
export default function buildMakeUser({ Id, isValidEmail, hashPassword }) {
return function makeUser({
id = Id.makeId(),
firstName,
lastName,
email,
password, // Nous prenons maintenant un mot de passe simple et le gérons à l'intérieur
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('L'utilisateur doit avoir un identifiant valide.');
}
if (!firstName || firstName.length < 2) {
throw new Error('Le prénom doit contenir au moins 2 caractères.');
}
if (!lastName || lastName.length < 2) {
throw new Error('Le nom de famille doit contenir au moins 2 caractères.');
}
if (!email || !isValidEmail(email)) {
throw new Error('L'utilisateur doit avoir une adresse e-mail valide.');
}
if (!password || password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères.');
}
// La normalisation et la transformation des données se font ici
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt
});
}
}
Désormais, toute partie de notre système qui souhaite créer un `User` doit passer par cette fabrique. Nous obtenons une validation garantie à chaque fois. Nous avons également encapsulé la logique de hachage du mot de passe et de normalisation de l'adresse e-mail. Le reste de l'application n'a pas besoin de connaître ou de se soucier de ces détails.
3. Logique métier : encapsulation du comportement
Notre objet `User` est encore un peu anémique. Il contient des données, mais il ne *fait* rien. Ajoutons un comportement : des méthodes qui représentent des actions spécifiques au domaine.
// ... à l'intérieur de la fonction makeUser ...
if (!password || password.length < 8) {
// ...
}
const passwordHash = hashPassword(password);
const normalizedEmail = email.toLowerCase();
return Object.freeze({
getId: () => id,
getFirstName: () => firstName,
getLastName: () => lastName,
getEmail: () => normalizedEmail,
getPasswordHash: () => passwordHash,
getCreatedAt: () => createdAt,
// Logique métier/comportement
getFullName: () => `${firstName} ${lastName}`,
// Une méthode qui décrit une règle métier
canVote: () => {
// Dans certains pays, l'âge de vote est de 18 ans. C'est une règle métier.
// Supposons que nous ayons une propriété dateOfBirth.
const age = calculateAge(dateOfBirth);
return age >= 18;
}
});
// ...
La logique `getFullName` n'est plus dispersée dans un contrôleur aléatoire ; elle appartient à l'entité `User` elle-même. Toute personne disposant d'un objet `User` peut désormais obtenir de manière fiable le nom complet en appelant `user.getFullName()`. La logique est définie une seule fois, en un seul endroit.
Construction d'un exemple pratique : un système de commerce électronique simple
Appliquons ce modèle à un domaine plus interconnecté. Nous allons modéliser un `Product`, un `OrderItem` et un `Order`.
1. Modélisation de l'entité `Product`
Un produit a un nom, un prix et des informations sur le stock. Il doit avoir un nom et son prix ne peut pas être négatif.
// fichier : /domain/product.js
export default function buildMakeProduct({ Id }) {
return function makeProduct({
id = Id.makeId(),
name,
description,
price,
stock = 0
}) {
if (!Id.isValidId(id)) {
throw new Error('Le produit doit avoir un ID valide.');
}
if (!name || name.trim().length < 2) {
throw new Error('Le nom du produit doit contenir au moins 2 caractères.');
}
if (isNaN(price) || price <= 0) {
throw new Error('Le produit doit avoir un prix supérieur à zéro.');
}
if (isNaN(stock) || stock < 0) {
throw new Error('Le stock doit être un nombre non négatif.');
}
return Object.freeze({
getId: () => id,
getName: () => name,
getDescription: () => description,
getPrice: () => price,
getStock: () => stock,
// Logique métier
isAvailable: () => stock > 0,
// Une méthode qui modifie l'état en renvoyant une nouvelle instance
reduceStock: (amount) => {
if (amount > stock) {
throw new Error('Stock insuffisant.');
}
// Renvoie un NOUVEL objet produit avec le stock mis à jour
return makeProduct({ id, name, description, price, stock: stock - amount });
}
});
}
}
Notez la méthode `reduceStock`. Il s'agit d'un concept crucial lié à l'immuabilité. Au lieu de modifier la propriété `stock` sur l'objet existant, elle renvoie une *nouvelle* instance `Product` avec la valeur mise à jour. Cela rend les changements d'état explicites et prévisibles.
2. Modélisation de l'entité `Order` (la racine d'agrégation)
Une `Order` est plus complexe. C'est ce que la conception axée sur le domaine (DDD) appelle une "racine d'agrégation". C'est une entité qui gère d'autres objets plus petits à l'intérieur de sa limite. Une `Order` contient une liste de `OrderItem`. Vous n'ajoutez pas un produit directement à une commande ; vous ajoutez un `OrderItem` qui contient un produit et une quantité.
// fichier : /domain/order.js
export const ORDER_STATUS = {
PENDING: 'PENDING',
PAID: 'PAID',
SHIPPED: 'SHIPPED',
CANCELLED: 'CANCELLED'
};
export default function buildMakeOrder({ Id, validateOrderItem }) {
return function makeOrder({
id = Id.makeId(),
customerId,
items = [],
status = ORDER_STATUS.PENDING,
createdAt = new Date()
}) {
if (!Id.isValidId(id)) {
throw new Error('La commande doit avoir un ID valide.');
}
if (!customerId) {
throw new Error('La commande doit avoir un ID client.');
}
let orderItems = [...items]; // Créer une copie privée à gérer
return Object.freeze({
getId: () => id,
getCustomerId: () => customerId,
getItems: () => [...orderItems], // Renvoie une copie pour empêcher toute modification externe
getStatus: () => status,
getCreatedAt: () => createdAt,
// Logique métier
calculateTotal: () => {
return orderItems.reduce((total, item) => {
return total + (item.getPrice() * item.getQuantity());
}, 0);
},
addItem: (item) => {
// validateOrderItem est une fonction qui garantit que l'élément est une entité OrderItem valide
validateOrderItem(item);
// Règle métier : empêcher l'ajout de doublons, augmenter simplement la quantité
const existingItemIndex = orderItems.findIndex(i => i.getProductId() === item.getProductId());
if (existingItemIndex > -1) {
const newQuantity = orderItems[existingItemIndex].getQuantity() + item.getQuantity();
// Ici, vous mettriez à jour la quantité sur l'élément existant
// (Cela nécessite que les éléments soient mutables ou qu'ils aient une méthode de mise à jour)
} else {
orderItems.push(item);
}
},
markPaid: () => {
if (status !== ORDER_STATUS.PENDING) {
throw new Error('Seules les commandes en attente peuvent être marquées comme payées.');
}
// Renvoie une nouvelle instance Order avec le statut mis à jour
return makeOrder({ id, customerId, items: orderItems, status: ORDER_STATUS.PAID, createdAt });
}
});
}
}
Cette entité `Order` applique désormais des règles métier complexes :
- Elle gère sa propre liste d'articles.
- Elle sait comment calculer son propre total.
- Elle applique des transitions d'état (par exemple, vous ne pouvez marquer qu'une commande `PENDING` comme `PAID`).
La logique métier des commandes est désormais soigneusement encapsulée dans ce module, testable isolément et réutilisable dans toute votre application.
Modèles avancés et considérations
Immuabilité : la pierre angulaire de la prévisibilité
Nous avons abordé l'immuabilité. Pourquoi est-ce si important ? Lorsque les objets sont immuables, vous pouvez les transmettre dans votre application sans craindre qu'une fonction distante ne modifie leur état de manière inattendue. Cela élimine toute une classe de bogues et facilite grandement la compréhension du flux de données de votre application.
Object.freeze() fournit un gel peu profond. Pour les entités avec des objets ou des tableaux imbriqués (comme notre `Order`), vous devez être plus prudent. Par exemple, dans `order.getItems()`, nous avons renvoyé une copie (`[...orderItems]`) pour empêcher l'appelant d'insérer directement des éléments dans le tableau interne de la commande.
Pour les applications complexes, des bibliothèques comme Immer peuvent faciliter grandement le travail avec des structures imbriquées immuables, mais le principe de base reste le même : traitez vos entités comme des valeurs immuables. Lorsqu'une modification doit avoir lieu, créez une nouvelle valeur.
Gestion des opérations asynchrones et de la persistance
Vous avez peut-être remarqué que nos entités sont entièrement synchrones. Elles ne savent rien des bases de données ou des API. C'est intentionnel et une force majeure du modèle !
Les entités ne doivent pas s'enregistrer elles-mêmes. Le travail d'une entité consiste à appliquer les règles métier. Le travail d'enregistrement des données dans une base de données appartient à une couche différente de votre application, souvent appelée couche de service, couche de cas d'utilisation ou modèle de référentiel.
Voici comment ils interagissent :
// fichier : /use-cases/create-user.js
// Ce cas d'utilisation dépend de la fabrique d'entités utilisateur et d'une fonction d'accès à la base de données.
export default function makeCreateUser({ makeUser, usersDatabase }) {
return async function createUser(userInfo) {
// 1. Créer une entité de domaine valide. Cette étape valide les données.
const user = makeUser(userInfo);
// 2. Vérifier les règles métier qui nécessitent des données externes (par exemple, l'unicité de l'e-mail)
const exists = await usersDatabase.findByEmail({ email: user.getEmail() });
if (exists) {
throw new Error('L'adresse e-mail est déjà utilisée.');
}
// 3. Persister l'entité. La base de données a besoin d'un objet simple.
const persisted = await usersDatabase.insert({
id: user.getId(),
firstName: user.getFirstName(),
// ... et ainsi de suite
});
return persisted;
}
}
Cette séparation des préoccupations est puissante :
- L'entité `User` est pure, synchrone et facile à tester unitairement.
- Le cas d'utilisation `createUser` est responsable de l'orchestration et peut être testé par intégration avec une base de données simulée.
- Le module `usersDatabase` est responsable de la technologie de base de données spécifique et peut être testé séparément.
Sérialisation et désérialisation
Vos entités, avec leurs méthodes, sont des objets riches. Mais lorsque vous envoyez des données sur un réseau (par exemple, dans une réponse d'API JSON) ou que vous les stockez dans une base de données, vous avez besoin d'une représentation de données simple. Ce processus est appelé sérialisation.
Un modèle courant consiste à ajouter une méthode `toJSON()` ou `toObject()` à votre entité.
// ... à l'intérieur de la fonction makeUser ...
return Object.freeze({
getId: () => id,
// ... autres getters
// Méthode de sérialisation
toObject: () => ({
id,
firstName,
lastName,
email: normalizedEmail,
createdAt
// Remarquez que nous n'incluons pas le passwordHash
})
});
Le processus inverse, qui consiste à prendre des données simples d'une base de données ou d'une API et à les retransformer en une entité de domaine riche, est exactement ce à quoi sert votre fonction de fabrique `makeUser`. C'est la désérialisation.
Typage avec TypeScript ou JSDoc
Bien que ce modèle fonctionne parfaitement en JavaScript pur, l'ajout de types statiques avec TypeScript ou JSDoc le suralimente. Les types vous permettent de définir formellement la "forme" de votre entité, offrant une excellente saisie semi-automatique et des vérifications au moment de la compilation.
// fichier : /domain/user.ts
// Définir l'interface publique de l'entité
export type User = Readonly<{
getId: () => string;
getFirstName: () => string;
// ... etc
getFullName: () => string;
}>;
// La fonction de fabrique renvoie maintenant le type User
export default function buildMakeUser(...) {
return function makeUser(...): User {
// ... implémentation
}
}
Les avantages globaux du modèle d'entité de module
En adoptant ce modèle, vous bénéficiez d'une multitude d'avantages qui se multiplient au fur et à mesure que votre application se développe :
- Source unique de vérité : Les règles métier et la validation des données sont centralisées et sans ambiguïté. Une modification d'une règle est effectuée à un seul endroit.
- Cohésion élevée, couplage faible : Les entités sont autonomes et ne dépendent pas des systèmes externes. Cela rend votre base de code modulaire et facile à refactoriser.
- Testabilité suprême : Vous pouvez écrire des tests unitaires simples et rapides pour votre logique métier la plus critique sans simuler le monde entier.
- Expérience de développement améliorée : Lorsqu'un développeur a besoin de travailler avec un `User`, il dispose d'une API claire, prévisible et auto-documentée à utiliser. Plus besoin de deviner la forme des objets simples.
- Une base pour l'évolutivité : Ce modèle vous offre un noyau stable et fiable. Au fur et à mesure que vous ajoutez des fonctionnalités, des frameworks ou des composants d'interface utilisateur, votre logique métier reste protégée et cohérente.
Conclusion : Construisez un noyau solide pour votre application
Dans un monde de frameworks et de bibliothèques en évolution rapide, il est facile d'oublier que ces outils sont transitoires. Ils changeront. Ce qui perdure, c'est la logique de base de votre domaine métier. Investir du temps dans la modélisation appropriée de ce domaine n'est pas seulement un exercice académique ; c'est l'un des investissements à long terme les plus importants que vous puissiez faire dans la santé et la longévité de votre logiciel.
Le modèle d'entité de module JavaScript fournit un moyen simple, puissant et natif de mettre en œuvre ces idées. Il ne nécessite pas de framework lourd ni de configuration complexe. Il exploite les fonctionnalités fondamentales du langage (modules, fonctions et fermetures) pour vous aider à construire un noyau propre, résilient et compréhensible pour votre application. Commencez par une entité clé dans votre prochain projet. Modélisez ses propriétés, validez sa création et donnez-lui un comportement. Vous ferez ainsi le premier pas vers une architecture logicielle plus robuste et plus professionnelle.